fix: stop stratum TCP connection leak under reconnect load#3889
Conversation
Rework stratum accept/session handling so abandoned clients cannot pin file descriptors indefinitely: - Always free the worker slot via Drop guard on disconnect - Idle timeout (5m) for silent/half-open sessions - Cap concurrent workers (256) and reject excess accepts - Bounded per-worker write queue; drop slow/full peers - Write timeout; max RPC line length - Reuse disconnected worker_stats slots instead of growing forever - Idempotent remove_worker (no panic on double cleanup) Addresses mimblewimble#3867.
Note: overlap with #3876Both this PR and #3876 address #3867 with the same structural fix:
#3876 is a smaller, cleaner refactor of that path (still uses unbounded queues; no idle timeout / worker cap). This PR builds on the same idea and also bounds resources that keep half-open / abandoned sessions alive under reconnect load:
Happy to adjust the numeric limits if maintainers prefer different defaults. Also removed a stray |
Add integration-style tests that spin a real accept loop on an ephemeral port, simulate reconnecting miners, assert worker count and FD growth stay bounded, and verify the max concurrent worker cap rejects surplus clients.
Live reconnect / FD tests (now automated)I initially only ran unit tests; the “run a node + reconnecting miner” items were manual and not executed yet. That was a gap. They are now covered by automated tests in-tree: cargo test -p grin_servers --lib mining::stratumserver::tests::test_live -- --test-threads=1
Both passed locally ( Still not a full mainnet node + grin-miner run, but it exercises the real accept/ |
Script boots grin --usernet with stratum enabled, drives reconnecting TCP "miners", and asserts process FD count and established stratum sockets stay bounded after clients disconnect (issue mimblewimble#3867).
Full-node soak now run./scripts/stratum_reconnect_soak.shResult: PASS (local)
This exercises the real binary accept path (not only the unit-test harness). Script lives at |
Pre-fill WorkersList to MAX_STRATUM_WORKERS with dummy channels instead of opening 256+ concurrent TCP sockets, which was unreliable under load (only ~117 workers registered). Still asserts surplus TCP accepts do not push the count past the cap.
Parameterize handle_connection idle timeout and add a live test that connects, stays silent past a short timeout, and asserts the worker is removed and the TCP session is closed. Production still uses the 5-minute WORKER_IDLE_TIMEOUT constant.
Idle miner disconnect confirmedAdded
cargo test -p grin_servers --lib mining::stratumserver::tests::test_live_idle_miner_disconnected -- --nocapture
# okFull suite: 14 passed. |
wiesche89
left a comment
There was a problem hiding this comment.
Thanks for addressing the leak. Since #3876 already provides the same structural fix in a smaller, more focused change, I think it would be the better base. We could add the relevant Rust regression tests there and handle the resource limits separately, as the current combined approach introduces new concurrency issues. The one off soak script should also stay out of the repository.
| match listener.accept().await { | ||
| Ok((socket, peer_addr)) => { | ||
| // Hard cap concurrent workers to avoid FD exhaustion. | ||
| if handler.workers.count() >= MAX_STRATUM_WORKERS { |
There was a problem hiding this comment.
This check runs before the spawned task registers the worker, so a burst of accepts can exceed the limit. Could the cap be enforced atomically inside add_worker?
| } | ||
| for id in slow { | ||
| warn!("Stratum: dropping slow/disconnected worker {}", id); | ||
| self.remove_worker(id); |
There was a problem hiding this comment.
Since worker IDs are reused, the old connection guard could remove a new worker that inherited the same ID. Could removal verify a generation token, or be left to the connection task?
| .expect("Stratum: no such addr in map"); | ||
| if workers_list.remove(&worker_id).is_none() { | ||
| // Already removed (e.g. concurrent cleanup); still refresh counts. | ||
| let mut stratum_stats = self.stratum_stats.write(); |
There was a problem hiding this comment.
add_worker takes stratum_stats before workers_list, but this branch takes them in the opposite order. Could we release workers_list before locking stratum_stats to avoid a possible deadlock?
| Some(line) => { | ||
| idle_deadline = Instant::now() + idle_timeout; | ||
| // Bound write time so a stalled peer cannot pin the task forever. | ||
| match timeout(Duration::from_secs(30), writer.send(line)).await { |
There was a problem hiding this comment.
could we make these 30 seconds a named constant as well, so all connection limits live in one place?
|
|
||
| loop { | ||
| tokio::select! { | ||
| biased; |
There was a problem hiding this comment.
Is the read first bias intentional? A continuously readable client can starve the writer until its response queue fills and the connection is dropped.
| @@ -0,0 +1,306 @@ | |||
| #!/usr/bin/env bash | |||
There was a problem hiding this comment.
Please remove this script from the PR. The relevant reconnect, FD cleanup, worker cap, and idle behavior is already covered by the automated Rust tests.
Summary
Fixes #3867 / reports of stratum leaving thousands of TCP sockets open until the process exits (
Too many open files, Windows loopback connection storms).Root causes addressed
futuresunbounded mpsc + job broadcast kept feeding dead peers without backpressure.select+ best-effortremove_worker(andexpectpanics) were fragile; worker_stats also grew forever.Changes
handle_connection+Dropguardremove_workerwhen the task endsLinesCodecmax 64 KiBremove_workerTests
test_worker_slot_reuse_after_disconnecttest_try_send_to_missing_full_and_oktest_remove_worker_is_idempotentcargo test -p grin_servers --lib mining::stratumserver -- --test-threads=1Test plan